11.1 提交和显示博客文章
1. 在app/models.py
中定义文章模型:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| # ... class Post(db.Model): __tablename__ = 'posts' id = db.Column(db.Integer, primary_key=True) body = db.Column(db.Text) body_html = db.Column(db.Text) timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow()) author_id = db.Column(db.Integer, db.ForeignKey('users.id')) class User(UserMixin, db.Model): # ... posts = db.relationship('Post', backref='author', lazy='dynamic')
|
说明:
- 为了使文章支持Markdown,用
body
字段存储markdown源文本,用body_html
字段存储markdown源文本转换成的HTML文本。(具体如何将markdown转换成HTML,可见第10步.)
2. 在app/main/forms.py
中定义文章表单:
1 2 3 4 5 6
| from flask_pagedown import PageDownField # ... class PostForm(FlaskForm): body = PageDownField("What's your mind?", validators=[DataRequired()]) submit = SubmitField('Submit')
|
3. 在app/main/views.py
中定义处理博客文章的主页路由:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| from app.models import Post # ... @main.route('/', methods=['GET', 'POST']) def index(): form = PostForm() if current_user.can(Permission.WRITE_ARTICLES) and form.validate_on_submit(): post = Post(body=form.body.data, author=current_user._get_current_object()) # 赋予当前用户对象 db.session.add(post) db.session.commit() # 当设置了请求结束后自动提交数据库变化时,该行可省略 return redirect(url_for('main.index')) page = request.args.get('page', 1, type=int) pagination = Post.query.order_by(Post.timestamp.desc()).paginate( page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'], error_out=False) posts = pagination.items # 获取当前分页对象的所有记录 return render_template('index.html', form=form, posts=posts, pagination=pagination)
|
current_user
是Flask-Login提供的,其表现类似用户对象,但实际上是一个轻度包装而由包含真正用户对象的对象。数据库需要获取真正的用户对象,所以需调用_get_current_objet()
方法获取。
- 客户端通过URL请求的页码数通过
requset.args.get()
方法获取,request.args.get('pag', 1, type=int)
通过关键字'page'
获取页码,如果没有指定页码,则默认请求第1页,type=int
保证参数无法转换成整数时,返回默认值(即第一页)。
paginate()
方法返回的是一个Pagination
类对象(分页对象)。该方法第一个参数(必需要有)为页码,可选参数per_page
用来指定每一页显示的记录数,如果没有指定,默认显示20个记录;另一个可选参数error_out
,当其设为True
时(默认值),如果请求的页码超出范围,则返回404
错误;如果设为False
,则返回一个空列表。常用的分页对象属性如表11-1。
表11-1 Flask-SQLAlchemy分页对象的属性
属性 |
说明 |
items |
当前页面中的记录 |
query |
分页的源查询 |
page |
当前页码 |
prev_num |
上一页的页码 |
next_num |
下一页的页码 |
has_next |
如果有下一页,则返回True |
has_prev |
如果有上一页,则返回True |
pages |
查询得到的总页数 |
per_page |
每页显示的记录数量 |
total |
查询返回的记录总数 |
在分页对象上还可以调用一些方法,如表11-2
表11-2 在Flask-SQLAlchemy对象上可调用的方法
方法 |
说明 |
iter_pages(left_edge=2, left_current=2, right_current=5, right_edge=2) |
一个迭代器,返回一个在分页导航中显示的页数列表。这个列表的最左边显示left_edge个页码,当前页的左边显示left_current个页码,当前页的右边显示right_current个页码,最右边显示right_edge个页码。如按当前默认配置,在一个100页的列表中,当前页为第50页,则会返回一下页数:1、2、None、48、49、50、51、52、53、54、55、None、99、100。None表示页数之间的间隔。 |
prev() |
上一页的分页对象 |
next() |
下一页的分页对象 |
- 这样,主页中就会显示特定数量的文章,如果想看第2页中的文章,可在URL后加上查询字符串
?page=2
,那么视图函数就会通过request.args.get()
方法获取'page'
页码加以处理。
4. 在app/templates/_posts.html
中定义用于显示博客文章的局部模板:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| <ul class="posts"> {% for post in posts %} <li class="post"> <div class="post-thumbnail"> <a href="{{ url_for('main.user', username=post.author.username) }}"> <img class="img-rounded profile-thumbnail" src="{{ post.author.gravatar(size=40) }}"> </a> </div> <div class="post-content"> <div class="post-date">{{ moment(post.timestamp).fromNow() }}</div> <div class="post-author"><a href="{{ url_for('main.user', username=post.author.username) }}">{{ post.author.username }}</a></div> <div class="post-body"> {% if post.body_html %} {{ post.body_html | safe }} {% else %} {{ post.body }} {% endif %} </div> <div class="post-footer"> {% if current_user == post.author %} <a href="{{url_for('main.edit', id=post.id) }}"> <span class="label label-primary">Edit</span> </a> {% elif current_user.is_administrator() %} <a href="{{ url_for('main.edit', id=post.id) }}"> <span class="label label-danger">Edit [Admin]</span> </a> {% endif %} <a href="{{ url_for('main.post', id=post.id) }}"> <span class="label label-default">Permalink</span> </a> </div> </div> </li> {% endfor %} </ul>
|
- 因为在
index.html
模板中以及第7步的user.html
模板中都需要显示博客文章,为了避免代码重写,将用于显示博客文章的模板分离出来,再在需要的时候加以引用(通过include()
指令引用,如include '_post.html'
)。为了区分独立模板和局部模板,局部模板一般在模板名加下划线_
。
if post.body_html
解释见第11步。
5. 在app/templates/_macros.html
中定义分页导航条模板宏(显示上一页、1、2、3…下一页):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| {% macro pagination_widget(pagination, endpoint) %} <ul class="pagination"> <li{% if not pagination.has_prev %} class="disabled"{% endif %}> <a href="{% if pagination.has_prev %}{{ url_for(endpoint, page=pagination.prev_num, **kwargs) }}{% else %}#{% endif %}"> « </a> </li> {% for p in pagination.iter_pages() %} {% if p %} {% if p == pagination.page %} <li class="active"> <a href="{{ url_for(endpoint, page=p, **kwargs) }}">{{ p }}</a> </li> {% else %} <li> <a href="{{ url_for(endpoint, page=p, **kwargs) }}">{{ p }}</a> </li> {% endif %} {% else %} <li class="disable"><a href="#">…</a></li> {% endif %} {% endfor %} <li{% if not pagination.has_next %} class="disabled"{% endif %}> <a href="{% if pagination.has_next %}{{ url_for(endpoint, page=pagination.next_num, **kwargs) }}{% else %}#{% endif %}"> » </a> </li> </ul> {% endmacro %}
|
pagination_widget(pagination, endpoint)
分别接受分页对象和路由端点名作为参数。
- 宏参数中不用加入
**kwargs
,分页宏会把接受到的所有关键字参数传给url_for()
。这种方式也可以用在路由中,例如包含一个动态部分的资料页。
- 分页对象的
iter_pages()
方法返回一个页数列表。
- 分页链接通过
url_for()
生成。
6. 在app/templates/index.html
中引用_posts.html
和_macros.html
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} {% import "_macros.html" as macros %} {% block title %}Flasky{% endblock %} {% block page_content %} <div class="page-header"> <h1>Hello, {% if current_user.is_authenticated %}{{ current_user.username }}{% else %}Stranger{% endif %}!</h1> </div> <div> {% if current_user.can(Permission.WRITE_ARTICLES) %} {{ wtf.quick_form(form) }} {% endif %} </div> {% include '_posts.html' %} {% if pagination %} <div class="pagination"> {{ macros.pagination_widget(pagination, 'main.index') }} </div> {% endif %} {% endblock %} {% block scripts %} {{ super() }} {{ pagedown.include_pagedown() }} {% endblock %}
|
7. 在app/main/views.py
中定义在个人资料页面中显示该用户所写文章的路由:
1 2 3 4 5 6 7 8 9 10 11
| # ... @main.route('/user/<username>') def user(username): user = User.query.filter_by(username=username).first_or_404() page = request.args.get('page', 1, type=int) pagination = user.posts.order_by(Post.timestamp.desc()).paginate( page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'], error_out=False) posts = pagination.items return render_template('user.html', user=user, posts=posts, pagination=pagination)
|
11.4 使用Markdown和Flask-PageDown支持富文本文章
实现这个功能需要用到以下一些包:
- PageDown:使用javaScript实现客户端Markdown到HTML的转换程序。
- Flask-PageDown:为Flask包装的PageDown,把PageDown集成到Flask—WTF表单中。
- Markdown:使用Python实现的服务器端Markdown到HTML的转换程序。
- Bleach:使用Python实现的HTML清理器。
8. 在app/__init__.py
中初始化Flask-PageDown:
1 2 3 4 5 6 7 8 9
| from flask_pagedown import PageDown # ... pagedown = PageDown() def create_app(config_name): # ... pagedown = pagedown.init_app(app) # ...
|
9. 在app/templates/index.html
中添加Flask-PageDown模板声明:
1 2 3 4
| {% block scripts %} {{ super() }} {{ pagedown.include_pagedown() }} {% endblock %}
|
10. 在app/models.py
中在Post模型中处理Markdown文本:
提交表单后,POST请求只会发送纯Markdown文本,页面中显示的HTML预览会被丢掉。
安全起见,我们(1)只提交Markdown源文件,(2)然后在服务器上使用Markdown将其转换为HTML文本,(3)得到HTML文本后,在使用Bleach进行清理,确保其中只包含几个允许使用的HTML标签,(4)将清理后得到的HTML文本存储到body_html
字段中以提高效率(不用每次请求时都转换)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| from markdown import markdown import bleach # ... class Post(db.Model): # ... body_html = db.Column(db.Text) # ... @staticmethod def on_changed_body(target, value, oldvalue, initiator): allowed_tags = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul', 'h1', 'h2', 'h3','p'] target.body_html = bleach.linkify(bleach.clean( markdown(value, output_format='html'), tags=allowed_tags, strip=True)) db.event.listen(Post.body, 'set', Post.on_changed_body)
|
on_changed_body
函数注册在body
字段上,是SQLAlchemy'set'
事件的监听程序,这意味着只要这个类实例的body字段设了新值,该函数就会被调用。
- 真正的Markdown转换成HTMl分三步完成:(1)
markdown()
函数把Markdown文本转换成HTML文本。(2)将得到的HTMl文本和允许使用的HTML标签列表传给clean()
函数,删除所有不再白名单中的HTML标签。(3)将上一步得到的HTML文本传给linkify()
函数,把纯文本中的URL链接转换成适当的<a>
链接(这一步是很必要的,因为Markdown规范没有为自动生成链接提供官方支持)。
11. 在app/templates/_posts.html
中判断是否使用文章的HTNL格式:
1 2 3 4 5 6 7 8
| # ... <div class="post-body"> {% if post.body_html %} {{ post.body_html | sage }} {% else %} {{ post.body }} {% endif %} </div>
|
- 渲染HTNL格式时使用
| safe
后缀,作用时告诉Jinja2不要转移HTML元素(默认情况下,出于安全考虑,Jinja2会转移所有模板变量)。
12. 在app/main/views.py
中定义文章页面路由:
1 2 3 4 5 6
| # ... @main.route('/post/<int:id>') def post(id): post = Post.query.get_or_404(id) return render_template('post.html', post=[post])
|
注意:post.html
模板中也通过引用_psots.html
显示博客文章,所以传参时要传一个列表(可迭代对象),因为_psots.html
中通过for
循环获取每一篇文章。
13. 在app/templates/_posts.html
中添加文章链接:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <ul class='posts"> {% for post in posts %} <li class="psot"> ... <div class="post-content"> .... <div class="post-footer"> <a href="{{ url_for('main.post', id=post.id) }}"> <span class="label label-default">Permalink</span> </a> </div> </div> </li> {% endfor %} </ul>
|
14. 在app/templates/post.html
中引用_posts.html
:
1 2 3 4 5 6 7 8
| {% extends "base.html" %} {% import "_macros.html" as macros %} {% block title %}Flasky - Post{% endblock %} {% block page_content %} {% include "_posts.html" %} {% endblock %}
|
11.6 博客文章编辑页面
15. 在app/main/views.py
中定义编辑文章的路由:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| # ... @main.route('/edit/<int:id>', methods=['GET', 'POST']) @login_required def edit(id): post = Post.query.get_or_404(id) if current_user != post.author and \ not current_user.can(Permission.ADMINISTER): abort(403) form = PostForm() if form.validate_on_submit(): post.body = form.body.data db.session.add(post) flash('The post has been updated.') return redirect(url_for('main.post', id=post.id)) form.body.data = post.body return render_template('edit_post.html', form=form)
|
注意:这个视图函数的作用是只允许博客文章作者编辑(管理员除外,管理员能编辑所有用户文章)。如果用户试图编辑其他用户的文章,视图函数则会返回403错误。这里使用的PostForm表单类和主页中使用的是同一个。
16. 在app/templates/edit_post.html
中定义编辑博客文章的模板:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} {% block title %}Flasky - Edit Post{% endblock %} {% block page_content %} <div class="page-header"> <h1>Edit Post</h1> </div> <div> {{ wtf.quick_form(form) }} </div> {% endblock %} {% block scripts %} {{ super() }} {{ pagedown.include_pagedown() }} {% endblock %}
|
17. 在app/templates/_posts.html
中添加一个在文章下面指向编辑文章页面的链接:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| <ul class='posts"> {% for post in posts %} <li class="psot"> ... <div class="post-content"> .... <div class="post-footer"> {% if current_user == post.author %} <a href="{{url_for('main.edit', id=post.id) }}"> <span class="label label-primary">Edit</span> </a> {% elif current_user.is_administrator() %} <a href="{{ url_for('main.edit', id=post.id) }}"> <span class="label label-danger">Edit [Admin]</span> </a> {% endif %} <a href="{{ url_for('main.post', id=post.id) }}"> <span class="label label-default">Permalink</span> </a> </div> </div> </li> {% endfor %} </ul>
|